Direkt - DRYNavigationManager made Swifty
Back in 2016, we published NavigationManager as an in-code alternative for Storyboard, where my colleague Jens Goeman introduced
DRYNavigationManager
, an in-code navigation framework for iOS. Since then the framework's been successfully used in multiple projects here at AppFoundry.
Nowadays though, Swift is our language of choice when working on new iOS applications. Both Objective-C and Swift share many similarities, but their leading programming paradigms differ distinctly. That's why we'd like to introduce you to the DRYNavigationManager
successor - Direkt
, implementation of the navigation concept in Swift.
The concept
The goal of the framework was to define a consistent and clean way of handling navigation between application screens without using storyboards and segues, from within the code.Requirements
Core requirements that we had in mind for the original framework stood strong when we approached Swift implementation:- Code separation - have a clear place in the code where navigation is handled
- Maximum extendability - there's no silver bullet for handling navigation, as it highly depends on your application.
Direkt
isn't trying to cover all possible scenarios instead, it simply shows where that logic should take place
Reinventing the wheel
If the requirements are the same, one might ask why reinvent the wheel? Well, we've got a pretty good reason for that, meaning type safety. Objective-C, contrary to Swift, is a very dynamic language. In the original implementation raw strings were used to determine which class will be responsible for executing the navigation logic. Another thing is the parameters that we need to pass to the next view controller on our navigation stack. Using heterogenous dictionary doesn't fit very well into Swift paradigms and makes it impossible to leverage its type system for compile-time code validation. Therefore our goal for Swift implementation was to build upon the defined concept and leverage modern programming features to make our code safer and more predictable.Implementation
Knowing the requirements and goal of the library, we can dive deep into the implementation details and see how solved the task.Direkt
defines three core protocols Navigator
, NavigationManager
and a Resolver
.
Resolver
The protocol is a minimal interface dependency injection. It is expected to be able to provide an instance of any type given some or none input. During navigation, it is used to create instances of navigators and target view controllers.func resolve<T>(_ type: T.Type, input: Input?) throws -> T
The somewhat similar concept is used e.g. in Swinject - Swift DI framework.
Due to lack of variadic generics in Swift, this approach is somewhat limited as we're able to pass only one input parameter. In most cases, it'd make sense to define a dedicated Input
structure that encapsulates various input parameters.
Navigator
From library user perspectiveNavigator
is the type that will be most often used. Basically, every view controller in the application that there's a need to navigate to, will have to have at least one Navigator
type defined. Navigators will have to take care of the navigation logic and passing of the Input
parameters.
The protocol requires implementation of one method:
public protocol Navigator {
associatedtype Input
func navigate(using input: Input, from hostViewController: UIViewController, resolver: Resolver) throws
}
Associated type Input
allows for a type safe definition of input parameters for given navigator.
Some of the logic is common between multiple screens, for instance being able to dismiss the currently presented view controller. In order to implement this, it's sufficient to define a navigator whom Input
is of type Void
.
Example of navigator that dismisses view controller:
class DismissingNavigator: Navigator {
func navigate(using input: Void, from hostViewController: UIViewController, resolver: Resolver) {
let presentingViewController = hostViewController.presentingViewController
presentingViewController?.dismiss(animated: true)
}
}
NavigationManager
As one might guess by its name,NavigationManager
manages the process of navigation. Its role is to abstract the way given Navigator
type is instantiated and how input parameters are passed further.
One of the core requirements of the library being extendability, the manager object has to implement only one method:
func navigate<T: Navigator>(to navigator: T.Type, using: T.Input, from hostViewController: UIViewController)
Direkt
provides a builtin base BasicNavigationManager
type that provides one customisable point of handling failed navigation requests. This could, for instance, be extended to provide immediate success/failure feedback to the callee instead.
Extending NavigationManager
The method required to implement by theNavigationManager
might seem somewhat limited at the first sight. In trivial cases, view controllers must know concrete type of a navigator that should be used, but this can be extended in various ways.
For instance, if dedicated types were used to distinguish input of view controllers, it'd be possible to type erase navigators and perform navigation logic purely based on input parameters.
// 1. Type erased navigator, that expects given Input type
public struct AnyNavigator: Navigator {
private let _navigate: (Input, UIViewController, Resolver) throws -> Void
public init<NavigatorType: Navigator>(
_ navigatorSource: @escaping @autoclosure () -> NavigatorType,
input: Input
) where NavigatorType.Input == Input {
self._navigate = {
try navigatorSource()
.navigate(using: $0, from: $1, resolver: $2)
}
}
public func navigate(
using input: Input,
from hostViewController: UIViewController,
resolver: Resolver
) throws {
try self._navigate(input, hostViewController, resolver)
}
}
// 2. Extend navigation manager to resolve navigator type based on a given input
public extension NavigationManager {
func navigate(
using input: Input,
from hostViewController: UIViewController
) {
self.navigate(
to: AnyNavigator.self,
using: input,
from: hostViewController
)
}
}
With navigation manager implementation that is able to resolve such dependencies, it'd be possible to only pass input on the callee site:
navigationManager?.navigate(using: HelloViewControllerInput(), from: self)
If you're already familiar with the DRYNavigationManager
this little cheat sheet might help you understand how Direkt
works.
DRYNavigationManager | Direkt | Summary |
---|---|---|
Navigator | Navigator | Protocol type that navigator objects conform to and execute the navigation logic |
NavigationManager | NavigationManager | Single object that encapsulates navigators and abstracts the way they are created. BaseNavigationManager is a simple implementation that |
NavigationDescriptor | N/A | Swifts type system and generics are used to resolve navigation parameters, there's no need for a dedicated object to handle them |
NavigationTranslator | Resolver | Instead of using raw strings to determine the target of navigation, Swift types are used |
Conclusion
We've managed to meet the core requirements posed to theDRYNavigationManager
by applying similar techniques as those used in the Objective-C implementation. On top of that, we improved the safety of our code by leveraging Swift programming paradigms. Yet as shown, the implementation defining basic building blocks remains extendable, even for more complex scenarios like dynamic navigation.
Hopefully reading this article made it more clear howDRYNavigationManager
consists of 395 lines of code and 32 test cases,Direkt
s implementation, largely thanks to Swifts type system, fits just in 48 lines and needs around 7 tests to guarantee 100% code coverage.
Direkt
can be used and how it can potentially be extended. If you'd like to contribute or use the library in your own project go ahead and checkout Direkt
GitHub repository.